跳到主要内容

组件实战:Watermark防删除水印组件

很多网页会加上水印,用于版权标识、防止盗用等。

比如这样:

ant designarco design 都提供了 Watermark 水印组件。

这种水印是咋实现的呢?

调试下就知道了:

arco design 的:

ant desigin 的:

首先,有一个 div 覆盖在需要加水印的区域,宽高 100%,绝对定位,设置 pointer-events:none 也就是不响应鼠标事件。

然后 background 设置 repeat,用 background image 平铺。

那这个 image 是什么呢?

点击这个 data url:

是个包含文字的图片。

而我们并没有传入图片作为参数:

所以说要用 canvas 画出来,做一些旋转,并导出 base64 的图片,作为这个 div 的背景就好了。

当然,也可以传入图片作为水印:

同样是用 canvas 画出来。

那怎么画呢?

根据传入的参数来画:

上面是 antd 的 Watermark 组件的参数。

可以传入宽高、旋转角度、字体样式、水印间距、水印偏移等。

虽然参数很多,但只是一些细节。

arco design 的 Watermark 组件画出的图片是上面的样子,所以 repeat 之后是这样的:

如果仔细看你会发现 ant design 的 Watermark 组件是这样的:

交错排列的。

这是因为它 canvas 画的内容就是交错的 2 个:

整体思路是很清晰的:用 canvas 把文字或者图片画出来,导出 base64 的 data url 设置为 div 的重复背景,这个 div 整个覆盖在需要加水印的元素上,设置 pointer-events 是 none。

此外,上节还讲过通过 MutationObserver 监听 dom 的修改,改了之后重新添加水印。

antd 就是这么做的:

思路理清了,我们来写下代码:

npx create-vite

去掉 index.css 和 StrictMode:

然后写下 Watermark/index.tsx

import { useRef, PropsWithChildren, CSSProperties, FC } from "react";

export interface WatermarkProps extends PropsWithChildren {
style?: CSSProperties;
className?: string;
zIndex?: string | number;
width?: number;
height?: number;
rotate?: number;
image?: string;
content?: string | string[];
fontStyle?: {
color?: string;
fontFamily?: string;
fontSize?: number | string;
fontWeight?: number | string;
};
gap?: [number, number];
offset?: [number, number];
getContainer?: () => HTMLElement;
}

const Watermark: FC<WatermarkProps> = (props) => {
const {
className,
style,
zIndex,
width,
height,
rotate,
image,
content,
fontStyle,
gap,
offset,
} = props;

const containerRef = useRef<HTMLDivElement>(null);

return props.children ? (
<div className={className} style={style} ref={containerRef}>
{props.children}
</div>
) : null;
};

export default Watermark;

style、className 就不用解释了。

width、height、rotate、offset、gap 等都是水印的参数:

gap 是两个水印之间的空白距离。

offset 是水印相对于 container 容器的偏移量,也就是左上角的空白距离。

然后我们封装个 useWatermark 的自定义 hook 来绘制水印:

import {
useRef,
PropsWithChildren,
CSSProperties,
FC,
useCallback,
useEffect,
} from "react";
import useWatermark from "./useWatermark";

export interface WatermarkProps extends PropsWithChildren {
style?: CSSProperties;
className?: string;
zIndex?: string | number;
width?: number;
height?: number;
rotate?: number;
image?: string;
content?: string | string[];
fontStyle?: {
color?: string;
fontFamily?: string;
fontSize?: number | string;
fontWeight?: number | string;
};
gap?: [number, number];
offset?: [number, number];
getContainer?: () => HTMLElement;
}

const Watermark: FC<WatermarkProps> = (props) => {
const {
className,
style,
zIndex,
width,
height,
rotate,
image,
content,
fontStyle,
gap,
offset,
} = props;

const containerRef = useRef<HTMLDivElement>(null);

const getContainer = useCallback(() => {
return props.getContainer
? props.getContainer()
: containerRef.current!;
}, [containerRef.current, props.getContainer]);

const { generateWatermark } = useWatermark({
zIndex,
width,
height,
rotate,
image,
content,
fontStyle,
gap,
offset,
getContainer,
});

useEffect(() => {
generateWatermark({
zIndex,
width,
height,
rotate,
image,
content,
fontStyle,
gap,
offset,
getContainer,
});
}, [
zIndex,
width,
height,
rotate,
image,
content,
JSON.stringify(props.fontStyle),
JSON.stringify(props.gap),
JSON.stringify(props.offset),
getContainer,
]);

return props.children ? (
<div className={className} style={style} ref={containerRef}>
{props.children}
</div>
) : null;
};

export default Watermark;

getContainer 默认用 containerRef.current,或者传入的 props.getContainer。

调用 useWatermark,返回 generateWatermark 方法。

然后当参数变化的时候,重新调用 generateWatermark 绘制水印。

getContainer 我们加了 useCallback 避免每次都变,对象参数(fontSize)、数组参数(gap、offset)用 JSON.stringify 序列化后再放到 deps 数组里:

然后来实现 useWatermark 的 hook。

新建 Watermark/useWatermark.ts

import { useEffect, useState } from "react";
import { WatermarkProps } from ".";
import { merge } from "lodash-es";

export type WatermarkOptions = Omit<
WatermarkProps,
"className" | "style" | "children"
>;

export default function useWatermark(params: WatermarkOptions) {
const [options, setOptions] = useState(params || {});

function drawWatermark() {}

useEffect(() => {
drawWatermark();
}, [options]);

return {
generateWatermark: (newOptions: Partial<WatermarkOptions>) => {
setOptions(merge({}, options, newOptions));
},
destroy: () => {},
};
}

参数就是 WatermarkProps 去了 style、className、children。

把传入的参数保存到 options 的 state,根据它渲染。

调用返回的 generateWatermark 的时候设置 options 触发重绘。

这里用到了 lodash-es 包的 merge 来合并参数。

安装下:

npm install --save lodash-es

npm i --save-dev @types/lodash-es

然后来处理下 options,和默认 options 做下合并:

这里的 toNumber 会把第一个参数转为 number,如果不是数字的话就返回第二个参数的默认值:

具体的合并逻辑是这样的:

image.png

先合并传入的 options

然后如果没有传入的会用默认值。

fontStyle 是默认 fontStyle 和传入的 fontStyle 的合并

width 的默认值,如果是图片就用默认 width,否则 undefined,因为后面文字宽度是动态算的。

offset 的默认值是 0。

因为处理完之后肯定是有值的,所以断言为 Required<WatermarkOptions> 类型。

这个 Required 是去掉可选用的,相对的,Partial 是给属性添加可选修饰。

合并完之后,就拿到绘制的 options 了。

import { useEffect, useRef, useState } from "react";
import { WatermarkProps } from ".";
import { merge } from "lodash-es";

export type WatermarkOptions = Omit<
WatermarkProps,
"className" | "style" | "children"
>;

export function isNumber(obj: any): obj is number {
return (
Object.prototype.toString.call(obj) === "[object Number]" && obj === obj
);
}

const toNumber = (value?: string | number, defaultValue?: number) => {
if (value === undefined) {
return defaultValue;
}
if (isNumber(value)) {
return value;
}
const numberVal = parseFloat(value);
return isNumber(numberVal) ? numberVal : defaultValue;
};

const defaultOptions = {
rotate: -20,
zIndex: 1,
width: 100,
gap: [100, 100],
fontStyle: {
fontSize: "16px",
color: "rgba(0, 0, 0, 0.15)",
fontFamily: "sans-serif",
fontWeight: "normal",
},
getContainer: () => document.body,
};

const getMergedOptions = (o: Partial<WatermarkOptions>) => {
const options = o || {};

const mergedOptions = {
...options,
rotate: options.rotate || defaultOptions.rotate,
zIndex: options.zIndex || defaultOptions.zIndex,
fontStyle: { ...defaultOptions.fontStyle, ...options.fontStyle },
width: toNumber(
options.width,
options.image ? defaultOptions.width : undefined
),
height: toNumber(options.height, undefined)!,
getContainer: options.getContainer!,
gap: [
toNumber(options.gap?.[0], defaultOptions.gap[0]),
toNumber(
options.gap?.[1] || options.gap?.[0],
defaultOptions.gap[1]
),
],
} as Required<WatermarkOptions>;

const mergedOffsetX = toNumber(mergedOptions.offset?.[0], 0)!;
const mergedOffsetY = toNumber(
mergedOptions.offset?.[1] || mergedOptions.offset?.[0],
0
)!;
mergedOptions.offset = [mergedOffsetX, mergedOffsetY];

return mergedOptions;
};

export default function useWatermark(params: WatermarkOptions) {
const [options, setOptions] = useState(params || {});

const mergedOptions = getMergedOptions(options);

function drawWatermark() {}

useEffect(() => {
drawWatermark();
}, [options]);

return {
generateWatermark: (newOptions: Partial<WatermarkOptions>) => {
setOptions(merge({}, options, newOptions));
},
destroy: () => {},
};
}

有了 options,接下来创建 dom,开始绘制:

用 useRef 保存水印元素的 dom。

调用 getCanvasData 方法来绘制,返回 base64Url、width、height 这些信息。

生成水印的 dom 元素,挂载到 container 下,设置 style。

注意 background-size 是 gap + width、gap + height 算出的。

接下来只要实现 getCanvasData 方法,用 cavas 画出水印就好了。

import { useEffect, useRef, useState } from "react";
import { WatermarkProps } from ".";
import { merge } from "lodash-es";

export type WatermarkOptions = Omit<
WatermarkProps,
"className" | "style" | "children"
>;

export function isNumber(obj: any): obj is number {
return (
Object.prototype.toString.call(obj) === "[object Number]" && obj === obj
);
}

const toNumber = (value?: string | number, defaultValue?: number) => {
if (!value) {
return defaultValue;
}
if (isNumber(value)) {
return value;
}
const numberVal = parseFloat(value);
return isNumber(numberVal) ? numberVal : defaultValue;
};

const defaultOptions = {
rotate: -20,
zIndex: 1,
width: 100,
gap: [100, 100],
fontStyle: {
fontSize: "16px",
color: "rgba(0, 0, 0, 0.15)",
fontFamily: "sans-serif",
fontWeight: "normal",
},
getContainer: () => document.body,
};

const getMergedOptions = (o: Partial<WatermarkOptions>) => {
const options = o || {};

const mergedOptions = {
...options,
rotate: options.rotate || defaultOptions.rotate,
zIndex: options.zIndex || defaultOptions.zIndex,
fontStyle: { ...defaultOptions.fontStyle, ...options.fontStyle },
width: toNumber(
options.width,
options.image ? defaultOptions.width : undefined
),
height: toNumber(options.height, undefined)!,
getContainer: options.getContainer!,
gap: [
toNumber(options.gap?.[0], defaultOptions.gap[0]),
toNumber(
options.gap?.[1] || options.gap?.[0],
defaultOptions.gap[1]
),
],
} as Required<WatermarkOptions>;

const mergedOffsetX = toNumber(mergedOptions.offset?.[0], 0)!;
const mergedOffsetY = toNumber(
mergedOptions.offset?.[1] || mergedOptions.offset?.[0],
0
)!;
mergedOptions.offset = [mergedOffsetX, mergedOffsetY];

return mergedOptions;
};

const getCanvasData = async (
options: Required<WatermarkOptions>
): Promise<{ width: number; height: number; base64Url: string }> => {};

export default function useWatermark(params: WatermarkOptions) {
const [options, setOptions] = useState(params || {});

const mergedOptions = getMergedOptions(options);
const watermarkDiv = useRef<HTMLDivElement>();

const container = mergedOptions.getContainer();
const { zIndex, gap } = mergedOptions;

function drawWatermark() {
if (!container) {
return;
}

getCanvasData(mergedOptions).then(({ base64Url, width, height }) => {
const wmStyle = `
width:100%;
height:100%;
position:absolute;
top:0;
left:0;
bottom:0;
right:0;
pointer-events: none;
z-index:${zIndex};
background-position: 0 0;
background-size:${gap[0] + width}px ${gap[1] + height}px;
background-repeat: repeat;
background-image:url(${base64Url})`;

if (!watermarkDiv.current) {
const div = document.createElement("div");
watermarkDiv.current = div;
container.append(div);
container.style.position = "relative";
}

watermarkDiv.current?.setAttribute("style", wmStyle.trim());
});
}

useEffect(() => {
drawWatermark();
}, [options]);

return {
generateWatermark: (newOptions: Partial<WatermarkOptions>) => {
setOptions(merge({}, options, newOptions));
},
destroy: () => {},
};
}

接下来实现 getCanvasData 方法。

创建个 canvas 元素,拿到画图用的 context。

封装 drawText、drawImage 两个方法,优先绘制 image。

然后封装个 configCanvas 方法,用来设置 canvas 的宽高、rotate、scale:

宽高同样是 gap + width、gap + height。

用 tanslate 移动中心点到 宽高的一半的位置再 schale、rotate。

因为不同屏幕的设备像素比不一样,也就是 1px 对应的物理像素不一样,所以要在单位后面乘以 devicePixelRatio。

我们设置了 scale 放大 devicePixelRatio 倍,这样接下来绘制尺寸就不用乘以设备像素比了。

const getCanvasData = async (
options: Required<WatermarkOptions>
): Promise<{ width: number; height: number; base64Url: string }> => {
const { rotate, image, content, fontStyle, gap } = options;

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;

const ratio = window.devicePixelRatio;

const configCanvas = (size: { width: number; height: number }) => {
const canvasWidth = gap[0] + size.width;
const canvasHeight = gap[1] + size.height;

canvas.setAttribute("width", `${canvasWidth * ratio}px`);
canvas.setAttribute("height", `${canvasHeight * ratio}px`);
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}px`;

ctx.translate((canvasWidth * ratio) / 2, (canvasHeight * ratio) / 2);
ctx.scale(ratio, ratio);

const RotateAngle = (rotate * Math.PI) / 180;
ctx.rotate(RotateAngle);
};

const drawText = () => {};

function drawImage() {}

return image ? drawImage() : drawText();
};

先来实现 drawImage:

function drawImage() {
return new Promise<{ width: number; height: number; base64Url: string }>(
(resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.referrerPolicy = "no-referrer";

img.src = image;
img.onload = () => {
let { width, height } = options;
if (!width || !height) {
if (width) {
height = (img.height / img.width) * +width;
} else {
width = (img.width / img.height) * +height;
}
}
configCanvas({ width, height });

ctx.drawImage(img, -width / 2, -height / 2, width, height);
return resolve({
base64Url: canvas.toDataURL(),
width,
height,
});
};
img.onerror = () => {
return drawText();
};
}
);
}

new Image 指定 src 加载图片。

onload 的时候,对于没有设置 width 或 height 的时候,根据图片宽高比算出另一个值。

然后调用 configCanvas 修改 canvas 的宽高、缩放、旋转。

之后在中心点绘制一张图片,返回 base64 的结果。

当加载失败时,onerror 里绘制文本。

这里的 crssOrign 设置 anonymous 是跨域的时候不携带 cookie,而 refererPolicy 设置 no-referrer 是不携带 referer,都是安全相关的。

然后实现 drawText:

const drawText = () => {
const { fontSize, color, fontWeight, fontFamily } = fontStyle;
const realFontSize = toNumber(fontSize, 0) || fontStyle.fontSize;

ctx.font = `${fontWeight} ${realFontSize}px ${fontFamily}`;
const measureSize = measureTextSize(ctx, [...content], rotate);

const width = options.width || measureSize.width;
const height = options.height || measureSize.height;

configCanvas({ width, height });

ctx.fillStyle = color!;
ctx.font = `${fontWeight} ${realFontSize}px ${fontFamily}`;
ctx.textBaseline = "top";

[...content].forEach((item, index) => {
const { height: lineHeight, width: lineWidth } =
measureSize.lineSize[index];

const xStartPoint = -lineWidth / 2;
const yStartPoint =
-(options.height || measureSize.originHeight) / 2 +
lineHeight * index;

ctx.fillText(
item,
xStartPoint,
yStartPoint,
options.width || measureSize.originWidth
);
});
return Promise.resolve({ base64Url: canvas.toDataURL(), height, width });
};

fontSize 转为 number。

如果没有传入 width、height 就自己计算,这个 measureTextSize 待会实现。

设置 textBaseline 为 top,顶部对齐。

然后依次绘制文字。

绘制文字要按照坐标来,在 measureTextSize 里计算出每一行的 lineSize,也就是行高、行宽。

在行宽的一半的地方开始绘制文字,行内每个文字的位置是行高的一半 * index。

然后实现 measureTextSize 方法:

const measureTextSize = (
ctx: CanvasRenderingContext2D,
content: string[],
rotate: number
) => {
let width = 0;
let height = 0;
const lineSize: Array<{ width: number; height: number }> = [];

content.forEach((item) => {
const {
width: textWidth,
fontBoundingBoxAscent,
fontBoundingBoxDescent,
} = ctx.measureText(item);

const textHeight = fontBoundingBoxAscent + fontBoundingBoxDescent;

if (textWidth > width) {
width = textWidth;
}

height += textHeight;
lineSize.push({ height: textHeight, width: textWidth });
});

const angle = (rotate * Math.PI) / 180;

return {
originWidth: width,
originHeight: height,
width: Math.ceil(
Math.abs(Math.sin(angle) * height) +
Math.abs(Math.cos(angle) * width)
),
height: Math.ceil(
Math.abs(Math.sin(angle) * width) +
Math.abs(height * Math.cos(angle))
),
lineSize,
};
};

ctx.measureText 是用来测量文字尺寸的。

fontBoudingAscent 是 baseline 到顶部的距离,而 fontBoundingBoxDescent 是到底部的距离:

加起来就是行高。

然后如果有旋转的话,要用 sin、cos 函数算出旋转后的宽高。

这样经过计算和绘制,文字和图片的水印就都完成了。

我们测试下:

改下 App.tsx

import Watermark from "./Watermark";

const App = () => {
return (
<Watermark content={["测试水印", "神说要有光"]}>
<div style={{ height: 800 }}>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
</div>
</Watermark>
);
};

export default App;

把 gap 设为 0:

import Watermark from "./Watermark";

const App = () => {
return (
<Watermark
content={["测试水印", "神说要有光"]}
gap={[0, 0]}
fontStyle={{
color: "green",
}}>
<div style={{ height: 800 }}>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
</div>
</Watermark>
);
};

export default App;

也没问题:

只是现在 offset 还没有支持,也就是左上角的空白距离。

这个就是改下 left、top 的值就好了,当然,width、height 也要从 100% 减去这块距离。

const offsetLeft = mergedOptions.offset[0] + "px";
const offsetTop = mergedOptions.offset[1] + "px";

const wmStyle = `
width:calc(100% - ${offsetLeft});
height:calc(100% - ${offsetTop});
position:absolute;
top:${offsetTop};
left:${offsetLeft};
bottom:0;
right:0;
pointer-events: none;
z-index:${zIndex};
background-position: 0 0;
background-size:${gap[0] + width}px ${gap[1] + height}px;
background-repeat: repeat;
background-image:url(${base64Url})`;

测试下:

import Watermark from "./Watermark";

const App = () => {
return (
<Watermark
content={["测试水印", "神说要有光"]}
gap={[0, 0]}
offset={[50, 100]}
fontStyle={{
color: "green",
}}>
<div style={{ height: 800 }}>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit.
Quos quod deserunt quidem quas in rem ipsam ut nesciunt
asperiores dignissimos recusandae minus, eaque, harum
exercitationem esse sapiente? Eveniet, id provident!
</p>
</div>
</Watermark>
);
};

export default App;

这样水印组件就完成了。

但现在的水印组件作用并不大,因为只要打开 devtools 就能轻易删掉。

我们要加上防删功能,前面讲过,用 MutationObserver:

创建完水印节点后,首先 disnonnect 去掉之前的 MutationObserver 的监听,然后创建新的 MutationObserver 监听 container 的变动。

export default function useWatermark(params: WatermarkOptions) {
const [options, setOptions] = useState(params || {});

const mergedOptions = getMergedOptions(options);
const watermarkDiv = useRef<HTMLDivElement>();
const mutationObserver = useRef<MutationObserver>();

const container = mergedOptions.getContainer();
const { zIndex, gap } = mergedOptions;

function drawWatermark() {
if (!container) {
return;
}

getCanvasData(mergedOptions).then(({ base64Url, width, height }) => {
const offsetLeft = mergedOptions.offset[0] + "px";
const offsetTop = mergedOptions.offset[1] + "px";

const wmStyle = `
width:calc(100% - ${offsetLeft});
height:calc(100% - ${offsetTop});
position:absolute;
top:${offsetTop};
left:${offsetLeft};
bottom:0;
right:0;
pointer-events: none;
z-index:${zIndex};
background-position: 0 0;
background-size:${gap[0] + width}px ${gap[1] + height}px;
background-repeat: repeat;
background-image:url(${base64Url})`;

if (!watermarkDiv.current) {
const div = document.createElement("div");
watermarkDiv.current = div;
container.append(div);
container.style.position = "relative";
}

watermarkDiv.current?.setAttribute("style", wmStyle.trim());

if (container) {
mutationObserver.current?.disconnect();

mutationObserver.current = new MutationObserver((mutations) => {
const isChanged = mutations.some((mutation) => {
let flag = false;
if (mutation.removedNodes.length) {
flag = Array.from(mutation.removedNodes).some(
(node) => node === watermarkDiv.current
);
}
if (
mutation.type === "attributes" &&
mutation.target === watermarkDiv.current
) {
flag = true;
}
return flag;
});
if (isChanged) {
watermarkDiv.current = undefined;
drawWatermark();
}
});

mutationObserver.current.observe(container, {
attributes: true,
subtree: true,
childList: true,
});
}
});
}

useEffect(() => {
drawWatermark();
}, [options]);

return {
generateWatermark: (newOptions: Partial<WatermarkOptions>) => {
setOptions(merge({}, options, newOptions));
},
destroy: () => {},
};
}

上节讲过,MutationObserver 可以监听子节点的变动和节点属性变动:

所以我们判断水印是否删除是通过判断是否修改了 watermark 节点的属性,是否增删了 watermark 节点:

是的话,就把 watermarkDiv.current 置空然后重新绘制。

测试下:

现在修改节点属性,或者删掉水印节点的时候,就会绘制一个新的。

这样,就达到了防止删除水印的功能。

案例代码上传了小册仓库

总结

这节我们实现了 Watermark 水印组件。

水印的实现原理就是加一个和目标元素宽高一样的 div 覆盖在上面,设置 pointer-events:none 不响应鼠标事件。

然后背景用水印图片 repeat 实现。

这个水印图片是用 canvas 画的,传入文字或者图片,会计算 gap、文字宽高等,在正确的位置绘制出来。

之后转成 base64 之后设置为 background-image。

此外,还要支持防删除功能,也就是用 MutationObserver 监听水印节点的属性变动、节点删除等,有变化就重新绘制一个。

这样,我们就实现了有防删功能的 Watermark 水印组件。